Skip to content

Fix phpstan/phpstan#14333: Setting an array key doesn't update a reference#5257

Open
phpstan-bot wants to merge 20 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-bsc1yhl
Open

Fix phpstan/phpstan#14333: Setting an array key doesn't update a reference#5257
phpstan-bot wants to merge 20 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-bsc1yhl

Conversation

@phpstan-bot
Copy link
Collaborator

Summary

When a variable is assigned as a by-reference item inside an array literal (e.g. $b = ['key' => &$a]), subsequent assignments to that array key (e.g. $b['key'] = 42) should propagate the type change to the referenced variable ($a). PHPStan was not tracking these array-to-variable references.

Changes

  • src/Analyser/ExprHandler/AssignHandler.php: After assigning a variable with an Array_ expression, inspect each array item for byRef references to variables. For each such item, create bidirectional IntertwinedVariableByReferenceWithExpr entries that link the array offset to the referenced variable, enabling type propagation in both directions.

  • src/Analyser/MutatingScope.php: In assignVariable(), preserve non-variable-to-variable intertwined refs (those involving ArrayDimFetch) across the propagation loop. Without this, the recursive assignVariable calls during propagation would inadvertently invalidate the intertwined refs, preventing subsequent array key assignments from propagating to referenced variables.

Root cause

PHPStan's reference tracking mechanism (IntertwinedVariableByReferenceWithExpr) was only set up for direct variable-to-variable references ($b = &$a), not for by-reference items inside array literals ($b = ['key' => &$a]). Additionally, the intertwined refs for array-to-variable references were being lost during the type propagation chain because each recursive assignVariable call would invalidate expressions containing the target variable, which included the intertwined ref nodes.

Test

Added tests/PHPStan/Analyser/nsrt/bug-14333.php with two test cases:

  1. testByRefInArrayWithKey() — named key: $b = ['key' => &$a]; $b['key'] = 42; verifies $a becomes 42
  2. testMultipleByRefInArray() — implicit indices with multiple refs: $b = [&$a, 'normal', &$c]; $b[0] = 2; $b[2] = 'bar'; verifies both $a and $c are updated

Fixes phpstan/phpstan#14333

…rence

- When creating an array with by-reference items (e.g. $b = ['key' => &$a]),
  register IntertwinedVariableByReferenceWithExpr entries so that subsequent
  assignments to array offsets propagate type changes to the referenced variables
- Preserve non-variable-to-variable intertwined refs in assignVariable() so they
  survive the recursive propagation chain without being invalidated
- New regression test in tests/PHPStan/Analyser/nsrt/bug-14333.php
Copy link
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add test for nested array

function testNested(): void
{
	$a = 1;

	$b = [[&$a]];
	assertType('1', $a);

	$b[0][0] = 2;

	assertType('2', $a);

	$b[0] = [];
	
	assertType('2', $a);

	$b[0][0] = 3;
	
	assertType('2', $a);
}

Nested arrays like `[[&$a]]` don't propagate reference updates through
`$b[0][0] = 2` to `$a`. This documents the current behavior as a known
limitation of the array reference tracking implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Collaborator Author

Already retrieved — all 11,625 tests passed. Everything is done and pushed.

phpstan-bot and others added 2 commits March 20, 2026 11:46
…constant keys

When a non-constant key appears in an array literal, the implicit index
counter becomes unreliable. Stop setting up intertwined refs for
implicit-keyed by-ref items after that point, since we can't know their
actual indices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VincentLanglet and others added 3 commits March 20, 2026 12:55
When a variable is referenced inside a nested array literal (e.g.
$b = [[&$a]]), assignments to the nested path ($b[0][0] = 2) now
correctly propagate to the referenced variable ($a).

The array reference setup in AssignHandler is refactored into a
recursive method that builds chained ArrayDimFetch expressions for
arbitrarily nested arrays.

When an intermediate array path is reassigned ($b[0] = []), the
nested intertwined refs are invalidated by checking whether the
dim-fetch chain still resolves to valid offsets, preventing stale
references from propagating incorrect types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ant keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…pression

Instead of preserving and restoring non-variable-to-variable intertwined refs
in assignVariable, handle them in invalidateExpression by skipping refs whose
endpoint variables (variableName, root of expr, root of assignedExpr) match
the variable being invalidated. Refs are still invalidated when a non-endpoint
variable (e.g. a dim index like $k in $a[$k]) changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
phpstan-bot and others added 2 commits March 20, 2026 13:09
…ession

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
phpstan-bot and others added 2 commits March 20, 2026 13:29
Move the `$assignedExpr->var instanceof ArrayDimFetch` guard into the
method itself and rename from isNestedDimFetchPathValid to
isDimFetchPathReachable for clarity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a failing test

function foo(array &$a): void {}

function testFunctionCall() {
	$b = 1;

	$c = [&$b];
	assertType('array{1}', $c);

	foo($c);
	assertType('array', $c);
	assertType('mixed', $b);
}

Currently PHPStan thinks $c is a non-empty-array and keep the offset but this is maybe not true since I might have unset the key 0.

Copy link
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test

function testFunctionCall() {
	$b = 1;

	$c = [&$b];
	assertType('array{1}', $c);

	foo($c);
	assertType('array', $c);
	assertType('mixed', $b);
}

is not passing because it currently think $c is a non-empty-array.

Fix the code.

… reassignment

When a variable like $c is completely reassigned (e.g. by a by-ref
function call), the propagation chain would update referenced variables
correctly ($b = mixed), but then the reverse ref would assign back to
$c[0], making $c appear as non-empty-array&hasOffsetValue(0, mixed)
instead of array. Skip the reverse assignment when the target's root
variable is already in the propagation chain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Collaborator Author

Already processed — static analysis passed with no errors. The fix has been committed and pushed.

Copy link
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this PR has a lot of parts changed. can something be extracted into smaller preparation PRs, so it gets easier to review?

phpstan-bot and others added 2 commits March 21, 2026 07:30
When a key has multiple possible scalar values (e.g. bool maps to 0 or 1),
the reference is tracked using the original key expression, so assignments
to either possible index correctly propagate through the reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…iple scalar keys

Array keys in PHP undergo type coercion (e.g. string "2" becomes int 2),
so use toArrayKey() before checking key values for implicit index tracking.
Also handle multiple-scalar keys correctly: string-only keys don't affect
implicit int indices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
phpstan-bot added a commit to phpstan-bot/phpstan-src that referenced this pull request Mar 21, 2026
Move the protection of IntertwinedVariableByReferenceWithExpr from a
separate check in invalidateExpression into shouldInvalidateExpression.
Remove the isVariableToVariableReference() method as it's no longer needed.

This is a preparation for array by-ref tracking in PR phpstan#5257.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Collaborator Author

That was the test suite task I already checked — all 11,625 tests passed. Everything is verified and complete.

@VincentLanglet
Copy link
Contributor

this PR has a lot of parts changed. can something be extracted into smaller preparation PRs, so it gets easier to review?

Not sure about it, I had to iterate a lot with the bot to get a fully working state.
If I split the PR, we'll end with multiple non-working state.

The three steps were

  • Basic implementation: 59ca979

  • Support for nested array 2407895

  • Stop the reference link when the key is overriden in the array f0ba82a

But I can still try after the week end if needed.

$array['key'] = 'aKey';

assertType("'a0'", $a);
assertType("'a2'", $b);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test are failing because '2' => &$b doesn't have the same behavior than 2 => &$b, but it should.

Fix this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already handled — all 11,625 tests passed. The fix has been committed and pushed.

Copy link
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since its extensive tested I am fine with it

…iple scalar keys

PHP coerces numeric string keys to integers (e.g., '2' => &$b behaves
like 2 => &$b). The dim expression used in intertwined refs must reflect
this coercion so that array access like $array[2] matches the ref
created from '2' => &$b.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
}
}

// Use the coerced key value for the dim expression so that
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldnt do this, cause this does not work for union for instance.

The fix needed seems to be somewhere else because the dimFetchExpr should be correctly understood

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Here's what was changed to address the review:

Problem: The dim expression coercion in processArrayByRefItems (converting String_('2') to Int_(2)) didn't work for union types and was placed in the wrong layer.

Fix: Two changes:

  1. AssignHandler.php: Removed the coercion block that created new scalar nodes for dim expressions. Now always uses $arrayItem->key directly. The toArrayKey() call is kept only for implicit index tracking.

  2. HasOffsetValueType.php: Added toArrayKey() coercion in hasOffsetValueType() and getOffsetValueType() before comparing offset types. This ensures ConstantStringType('2') correctly matches a stored ConstantIntegerType(2) key, handling PHP's array key coercion at the type system level rather than at expression construction time.

…nstead

Instead of coercing array key expressions (e.g. String_('2') → Int_(2))
when constructing intertwined ref dim expressions, use the original key
expression directly and fix HasOffsetValueType to apply toArrayKey()
coercion when matching offset types.

This properly handles PHP's array key coercion ('2' → 2) in the type
system rather than in expression construction, which works correctly
for union types as well.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants